iT邦幫忙

0

Minute 10 : Side Project如何用原生 Canvas 實現動態粒子系統與異步回能遊戲機制

  • 分享至 

  • xImage
  •  

用純 JS 刻出童年回憶!工程師打造「火柴人奧義對決」網頁版
你還記得小時候在學校電腦課,偷偷在畫面上畫火柴人決鬥的感動嗎?近日,一位熱血的前端工程師將這份童年情懷,用現代網頁技術進行了史詩級的魔改!
這款名為「火柴人:奧義對決(爆炸特效版)」的網頁小遊戲,完全不需要下載任何封包,只要打開瀏覽器就能直接在網頁上觀看一場驚心動魄的對決。
🏹 精靈弓箭手 vs 🛡️ 皇家劍盾兵:硬核機制大公開!
點進網頁,映入眼簾的是充滿科技感的深色漸層背景與極簡精緻的 UI 看板。這款遊戲是由前端工程師利用 Tailwind CSS 搭配原生 HTML5 Canvas 打造。
看它只是火柴人,遊戲內部的代碼邏輯可是一點都不含糊:

  • 獨立異步回能系統: 兩位角色擁有各自獨立的被動每幀回能速度(passiveMpGain),透過隨機變數讓每場戰鬥都充滿不可預測性
  • 真實重力與跳躍物理: 代碼內建重力模擬(gravity),讓火柴人的跳躍與落地回饋感更加逼真。
  • 震撼粒子與衝擊波(Boom Effects):只要點擊「雙方奧義大爆炸」,畫面就會瞬間觸發絢麗的粒子噴發與動態衝擊波擴散,視覺張力直接拉滿!
    如果你也想體驗滿滿的奧義衝擊波,或是想研究純 JavaScript 的 2D 遊戲物理,不妨趕快點開這個精緻的網頁作品,感受一下前端開發的無限魅力吧!
    https://jsbin.com/siwotikofu/edit?html,output

*提醒邦友,使用第三方服務/API 時,請務必評估資安風險與隱私保護
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>火柴人:奧義對決(爆炸特效版)</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
    <style>
        body {
            font-family: 'Inter', sans-serif;
            background: radial-gradient(circle at center, #141428 0%, #07070f 100%);
            color: #fff;
            min-height: 100vh;
            margin: 0;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            overflow-x: hidden;
        }
        canvas {
            background-color: #04040a;
            box-shadow: 0 25px 60px rgba(0, 0, 0, 0.9), inset 0 0 40px rgba(255, 255, 255, 0.03);
            border-radius: 18px;
            border: 2px solid #2d2d48;
            max-width: 100%;
            height: auto;
        }
    </style>
</head>
<body class="p-4 flex flex-col items-center">

    <div class="text-center mb-3">
        <h1 class="text-3xl font-extrabold tracking-wider text-transparent bg-clip-text bg-gradient-to-r from-amber-400 via-rose-400 to-indigo-500 drop-shadow-lg">
            ⚔️ 火柴人:奧義對決(爆炸特效版) 🛡️
        </h1>
        <p class="text-gray-400 text-xs sm:text-sm mt-1">精靈弓箭手 (P1) vs 皇家劍盾兵 (P2) · 獨立異步回能與震撼衝擊波模擬器</p>
    </div>

    <div class="w-[850px] max-w-full flex justify-between items-center mb-3 px-2">
        <!-- P1 資訊看板 -->
        <div class="flex flex-col w-[42%] bg-gray-900/80 p-3 rounded-2xl border border-gray-800 backdrop-blur-md shadow-lg">
            <div class="flex justify-between items-center mb-1 text-xs sm:text-sm font-bold">
                <span class="text-amber-400 flex items-center gap-1">🏹 精靈弓箭手 (P1)</span>
                <span id="p1HpText" class="text-gray-300 font-mono">100/100</span>
            </div>
            <div class="w-full bg-gray-800 h-3 rounded-full overflow-hidden border border-gray-700 shadow-inner mb-2">
                <div id="p1HpBar" class="h-full bg-gradient-to-r from-amber-600 to-amber-400 transition-all duration-200" style="width: 100%;"></div>
            </div>
            <div class="flex justify-between items-center text-[11px] text-cyan-400 font-semibold">
                <span id="p1MpLabel">奧義能量 (MP)</span>
                <span id="p1MpText" class="font-mono">0%</span>
            </div>
            <div class="w-full bg-gray-800 h-2 rounded-full overflow-hidden border border-gray-700 mt-1">
                <div id="p1MpBar" class="h-full bg-gradient-to-r from-cyan-600 to-cyan-400 transition-all duration-200" style="width: 0%;"></div>
            </div>
        </div>

        <div class="text-sm font-extrabold text-gray-500 px-2 animate-pulse">VS</div>

        <!-- P2 資訊看板 -->
        <div class="flex flex-col w-[42%] bg-gray-900/80 p-3 rounded-2xl border border-gray-800 backdrop-blur-md shadow-lg">
            <div class="flex justify-between items-center mb-1 text-xs sm:text-sm font-bold">
                <span id="p2HpText" class="text-gray-300 font-mono">100/100</span>
                <span class="text-rose-400 flex items-center gap-1">皇家劍盾兵 (P2) 🛡️</span>
            </div>
            <div class="w-full bg-gray-800 h-3 rounded-full overflow-hidden border border-gray-700 shadow-inner mb-2 flex justify-end">
                <div id="p2HpBar" class="h-full bg-gradient-to-r from-rose-400 to-red-600 transition-all duration-200" style="width: 100%;"></div>
            </div>
            <div class="flex justify-between items-center text-[11px] text-rose-400 font-semibold">
                <span id="p2MpText" class="font-mono">0%</span>
                <span id="p2MpLabel">奧義能量 (MP)</span>
            </div>
            <div class="w-full bg-gray-800 h-2 rounded-full overflow-hidden border border-gray-700 mt-1 flex justify-end">
                <div id="p2MpBar" class="h-full bg-gradient-to-r from-rose-400 to-red-500 transition-all duration-200" style="width: 0%;"></div>
            </div>
        </div>
    </div>

    <div class="relative shadow-2xl rounded-2xl overflow-hidden border border-gray-700/60">
        <canvas id="gameCanvas" width="850" height="420"></canvas>
    </div>

    <div class="mt-4 flex flex-wrap gap-3 justify-center">
        <button onclick="triggerForcedUlt()" class="px-5 py-2.5 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white font-semibold rounded-xl shadow-lg transform active:scale-95 transition-all cursor-pointer text-sm flex items-center gap-2 border border-purple-400/30">
            ✨ 雙方奧義大爆炸
        </button>
        <button onclick="togglePause()" id="pauseBtn" class="px-5 py-2.5 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl shadow-lg transform active:scale-95 transition-all cursor-pointer text-sm flex items-center gap-2 border border-gray-700">
            ⏸️ 暫停 / 繼續
        </button>
        <button onclick="manualReset()" class="px-5 py-2.5 bg-gray-800 hover:bg-gray-700 text-white font-semibold rounded-xl shadow-lg transform active:scale-95 transition-all cursor-pointer text-sm flex items-center gap-2 border border-gray-700">
            🔄 重新開始決鬥
        </button>
    </div>

    <script>
        const canvas = document.getElementById("gameCanvas");
        const ctx = canvas.getContext("2d");

        const GROUND_Y = 320;
        let arrows = [];
        let activeMegaSword = null;
        let boomEffects = []; // 爆炸衝擊波特效清單
        let particles = [];
        let floatingTexts = [];
        let gameOver = false;
        let restartTimer = 0;
        let isPaused = false;
        let screenShake = 0;

        function addFloatingText(text, x, y, color) {
            floatingTexts.push({ text, x, y, alpha: 1.0, vy: -1.8, color });
        }

        function createParticles(x, y, color, count, speedMult = 1) {
            for (let i = 0; i < count; i++) {
                const angle = Math.random() * Math.PI * 2;
                const speed = (1 + Math.random() * 6) * speedMult;
                particles.push({
                    x: x, y: y,
                    vx: Math.cos(angle) * speed,
                    vy: Math.sin(angle) * speed,
                    size: 2 + Math.random() * 5,
                    color: color,
                    alpha: 1,
                    decay: 0.02 + Math.random() * 0.03
                });
            }
        }

        function addBoomEffect(x, y, color) {
            boomEffects.push({
                x: x,
                y: y,
                radius: 10,
                maxRadius: 110,
                color: color,
                alpha: 1.0
            });
        }

        class Stickman {
            constructor(x, color, name, role) {
                this.x = x;
                this.y = GROUND_Y;
                this.color = color;
                this.name = name;
                this.role = role; // "archer" 或 "sword"
                this.direction = 1;
                this.hp = 100;
                this.mp = 0;
                this.state = "idle"; // idle, walking, attacking, hit, frozen
                this.stateTimer = 0;
                
                // 物理跳躍變數
                this.vy = 0;
                this.gravity = 0.5;
                this.isGrounded = true;

                // 各自獨立被動每幀回能速度(隨機非同步)
                this.passiveMpGain = 0.12 + Math.random() * 0.18;
            }

            update(opponent) {
                // 1. 各自獨立回能
                if (this.hp > 0 && opponent.hp > 0 && !gameOver && !isPaused) {
                    this.mp = Math.min(100, this.mp + this.passiveMpGain);
                }

                // 2. 重力與跳躍物理
                this.y += this.vy;
                if (this.y < GROUND_Y) {
                    this.vy += this.gravity;
                    this.isGrounded = false;
                } else {
                    this.y = GROUND_Y;
                    this.vy = 0;
                    this.isGrounded = true;
                }

                // 3. 定格或狀態計時
                if (this.state === "frozen") {
                    if (--this.stateTimer <= 0) this.state = "idle";
                    return;
                }

                if (this.stateTimer > 0) {
                    if (--this.stateTimer <= 0) this.state = "idle";
                }

                // 永遠面向對手
                this.direction = (opponent.x > this.x) ? 1 : -1;

                if (opponent.hp > 0) {
                    let dist = Math.abs(this.x - opponent.x);

                    // 【跳過對方換位機制】極度貼身時原地高跳跨越
                    if (dist < 60 && this.isGrounded && opponent.isGrounded && this.state === "idle") {
                        this.vy = -12;
                        this.x += this.direction * 95;
                        this.state = "walking";
                        this.stateTimer = 15;
                        createParticles(this.x, this.y, '#ffffff', 8);
                        return;
                    }

                    // 【大招判定】滿 100 能量釋放
                    if (this.mp >= 100 && this.state === "idle") {
                        this.mp = 0;
                        this.state = "attacking";
                        this.stateTimer = 22;
                        screenShake = 16;
                        
                        if (this.role === "archer") {
                            addFloatingText("奧義:爆裂穿心箭!", this.x, this.y - 110, '#38bdf8');
                            arrows.push({ x: this.x, y: this.y - 60, vx: this.direction * 13, owner: this, isUlt: true });
                        } else {
                            addFloatingText("奧義:天誅轟天劍陣!", this.x, this.y - 110, '#f43f5e');
                            activeMegaSword = { x: opponent.x, y: this.y - 260, targetY: opponent.y - 60, target: opponent };
                        }
                        return;
                    }

                    // 【一般 AI 行動】
                    if (this.role === "archer") {
                        if (dist < 180) {
                            this.x -= this.direction * 2.8;
                        }
                        if (this.state === "idle" && Math.random() < 0.04) {
                            this.state = "attacking";
                            this.stateTimer = 12;
                            arrows.push({ x: this.x, y: this.y - 60, vx: this.direction * 8, owner: this, isUlt: false });
                        }
                    } else {
                        if (this.state === "idle") {
                            if (dist > 55) {
                                this.x += this.direction * 2.5;
                            } else if (Math.random() < 0.15) {
                                this.state = "attacking";
                                this.stateTimer = 12;
                                if (dist < 60 && Math.abs(this.y - opponent.y) < 40 && opponent.state !== "frozen") {
                                    screenShake = 5;
                                    createParticles(opponent.x, opponent.y - 40, '#ffffff', 10);
                                    opponent.hp = Math.max(0, opponent.hp - 10);
                                    opponent.state = "hit";
                                    opponent.stateTimer = 10;
                                    if (!opponent.isGrounded) opponent.vy = 2;
                                    addFloatingText("-10", opponent.x, opponent.y - 80, '#ef4444');
                                    this.mp = Math.min(100, this.mp + 8);
                                }
                            }
                        }
                    }
                }

                if (this.x < 40) this.x = 40;
                if (this.x > canvas.width - 40) this.x = canvas.width - 40;
            }

            draw() {
                ctx.save();
                ctx.strokeStyle = (this.state === "frozen") ? "#facc15" : (this.state === "hit" ? "#ef4444" : this.color);
                ctx.lineWidth = 4;

                ctx.beginPath(); ctx.arc(this.x, this.y - 80, 12, 0, Math.PI * 2); ctx.stroke();
                ctx.beginPath(); ctx.moveTo(this.x, this.y - 68); ctx.lineTo(this.x, this.y - 40); ctx.stroke();
                
                ctx.beginPath(); 
                if (!this.isGrounded) {
                    ctx.moveTo(this.x, this.y - 40); ctx.lineTo(this.x - 6, this.y - 20); 
                    ctx.moveTo(this.x, this.y - 40); ctx.lineTo(this.x + 6, this.y - 20); 
                } else {
                    ctx.moveTo(this.x, this.y - 40); ctx.lineTo(this.x - 10, this.y); 
                    ctx.moveTo(this.x, this.y - 40); ctx.lineTo(this.x + 10, this.y); 
                }
                ctx.stroke();

                let sy = this.y - 60;
                if (this.role === "archer") {
                    ctx.beginPath(); 
                    ctx.arc(this.x + this.direction * 15, sy, 20, -Math.PI/2, Math.PI/2, this.direction < 0);
                    ctx.stroke();
                } else {
                    ctx.beginPath(); ctx.strokeStyle = "#ddd"; 
                    ctx.moveTo(this.x, sy); ctx.lineTo(this.x + this.direction * 35, this.state === "attacking" ? sy : sy - 20); ctx.stroke();
                    ctx.beginPath(); ctx.strokeStyle = "#888"; ctx.lineWidth = 6; 
                    ctx.moveTo(this.x + this.direction * 15, sy - 15); ctx.lineTo(this.x + this.direction * 15, sy + 25); ctx.stroke();
                }
                ctx.restore();
            }
        }

        let player1 = new Stickman(150, "#fbbf24", "精靈弓箭手", "archer");
        let player2 = new Stickman(650, "#f43f5e", "皇家劍盾兵", "sword");

        function updateEntities() {
            for (let i = arrows.length - 1; i >= 0; i--) {
                let a = arrows[i];
                a.x += a.vx;
                let opponent = (a.owner === player1) ? player2 : player1;

                if (Math.abs(a.x - opponent.x) < 25 && a.y > opponent.y - 90 && a.y < opponent.y) {
                    screenShake = a.isUlt ? 14 : 5;
                    createParticles(opponent.x, opponent.y - 40, a.isUlt ? '#38bdf8' : '#ffffff', a.isUlt ? 25 : 8, a.isUlt ? 1.5 : 1);
                    if (a.isUlt) {
                        addBoomEffect(opponent.x, opponent.y - 40, '#38bdf8');
                        opponent.hp = Math.max(0, opponent.hp - 28);
                        opponent.state = "hit";
                        opponent.stateTimer = 25;
                        opponent.x += (a.vx > 0 ? 1 : -1) * 220;
                        opponent.mp = Math.min(100, opponent.mp + 12);
                        addFloatingText("-28 (爆裂穿心)", opponent.x, opponent.y - 85, '#38bdf8');
                    } else {
                        let isFacing = (opponent.role === "sword" && ((a.vx < 0 && opponent.direction === 1) || (a.vx > 0 && opponent.direction === -1)));
                        if (isFacing && opponent.state !== "frozen" && opponent.state !== "hit") {
                            opponent.x += (a.vx > 0 ? 1 : -1) * 15;
                            opponent.mp = Math.min(100, opponent.mp + 10);
                            addFloatingText("格擋", opponent.x, opponent.y - 85, '#facc15');
                        } else {
                            opponent.hp = Math.max(0, opponent.hp - 8);
                            opponent.state = "hit";
                            opponent.stateTimer = 10;
                            opponent.x += (a.vx > 0 ? 1 : -1) * 30;
                            a.owner.mp = Math.min(100, a.owner.mp + 6);
                            addFloatingText("-8", opponent.x, opponent.y - 85, '#ef4444');
                        }
                    }
                    arrows.splice(i, 1);
                    continue;
                }
                if (a.x < 0 || a.x > canvas.width) arrows.splice(i, 1);
            }

            if (activeMegaSword) {
                activeMegaSword.y += 14;
                if (activeMegaSword.y >= activeMegaSword.targetY) {
                    let target = activeMegaSword.target;
                    screenShake = 22;
                    createParticles(target.x, target.y - 30, '#f43f5e', 35, 2);
                    addBoomEffect(target.x, target.y - 30, '#f43f5e');
                    
                    target.hp = Math.max(0, target.hp - 30);
                    target.state = "frozen";
                    target.stateTimer = 100;
                    target.vy = 0;
                    target.y = GROUND_Y;
                    target.mp = Math.min(100, target.mp + 15);
                    addFloatingText("-30 (天誅定格)", target.x, target.y - 85, '#f43f5e');
                    activeMegaSword = null;
                }
            }
        }

        function drawEntities() {
            arrows.forEach(a => {
                ctx.fillStyle = a.isUlt ? "#38bdf8" : "#fff";
                ctx.fillRect(a.x, a.y, a.isUlt ? 18 : 8, a.isUlt ? 7 : 3);
            });
            if (activeMegaSword) {
                ctx.fillStyle = "#f43f5e";
                ctx.fillRect(activeMegaSword.x - 7, activeMegaSword.y - 45, 14, 55); 
            }
            ctx.strokeStyle = '#2d2d48'; ctx.lineWidth = 3;
            ctx.beginPath(); ctx.moveTo(0, GROUND_Y); ctx.lineTo(canvas.width, GROUND_Y); ctx.stroke();
        }

        function updateUI() {
            document.getElementById('p1HpText').innerText = `${Math.ceil(player1.hp)}/100`;
            document.getElementById('p1HpBar').style.width = `${Math.max(0, player1.hp)}%`;
            document.getElementById('p1MpText').innerText = `${Math.floor(player1.mp)}%`;
            document.getElementById('p1MpBar').style.width = `${player1.mp}%`;
            document.getElementById('p1MpLabel').innerText = player1.mp >= 100 ? "⚡ 奧義就緒!" : "奧義能量 (MP)";

            document.getElementById('p2HpText').innerText = `${Math.ceil(player2.hp)}/100`;
            document.getElementById('p2HpBar').style.width = `${Math.max(0, player2.hp)}%`;
            document.getElementById('p2MpText').innerText = `${Math.floor(player2.mp)}%`;
            document.getElementById('p2MpBar').style.width = `${player2.mp}%`;
            document.getElementById('p2MpLabel').innerText = player2.mp >= 100 ? "⚡ 奧義就緒!" : "奧義能量 (MP)";
        }

        function triggerForcedUlt() {
            if (gameOver) return;
            player1.mp = 100;
            player2.mp = 100;
        }

        function togglePause() {
            isPaused = !isPaused;
        }

        function manualReset() {
            player1 = new Stickman(150, "#fbbf24", "精靈弓箭手", "archer");
            player2 = new Stickman(650, "#f43f5e", "皇家劍盾兵", "sword");
            arrows = [];
            activeMegaSword = null;
            boomEffects = [];
            particles = [];
            floatingTexts = [];
            gameOver = false;
            restartTimer = 0;
        }

        function gameLoop() {
            ctx.clearRect(0, 0, canvas.width, canvas.height);

            ctx.save();
            if (screenShake > 0) {
                const dx = (Math.random() - 0.5) * screenShake;
                const dy = (Math.random() - 0.5) * screenShake;
                ctx.translate(dx, dy);
                screenShake *= 0.82;
                if (screenShake < 0.4) screenShake = 0;
            }

            if (!isPaused && !gameOver) {
                player1.update(player2);
                player2.update(player1);
                updateEntities();
                if (player1.hp <= 0 || player2.hp <= 0) {
                    gameOver = true;
                    restartTimer = 180;
                }
            }

            drawEntities();
            player1.draw();
            player2.draw();

            // 爆炸衝擊波特效更新
            for (let i = boomEffects.length - 1; i >= 0; i--) {
                let b = boomEffects[i];
                b.radius += 5;
                b.alpha -= 0.04;
                if (b.alpha <= 0 || b.radius >= b.maxRadius) {
                    boomEffects.splice(i, 1);
                    continue;
                }
                ctx.save();
                ctx.strokeStyle = b.color;
                ctx.lineWidth = 4 * b.alpha;
                ctx.globalAlpha = b.alpha;
                ctx.beginPath();
                ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
                ctx.stroke();
                ctx.restore();
            }

            // 粒子更新
            for (let i = particles.length - 1; i >= 0; i--) {
                let p = particles[i];
                p.x += p.vx; p.y += p.vy; p.alpha -= p.decay;
                if (p.alpha <= 0) { particles.splice(i, 1); continue; }
                ctx.fillStyle = p.color; ctx.globalAlpha = p.alpha;
                ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
            }
            ctx.globalAlpha = 1.0;

            // 飄浮文字更新
            for (let i = floatingTexts.length - 1; i >= 0; i--) {
                let ft = floatingTexts[i];
                ft.y += ft.vy; ft.alpha -= 0.022;
                if (ft.alpha <= 0) { floatingTexts.splice(i, 1); continue; }
                ctx.save();
                ctx.font = "bold 18px Inter, sans-serif";
                ctx.fillStyle = ft.color; ctx.globalAlpha = ft.alpha;
                ctx.shadowBlur = 10; ctx.shadowColor = ft.color; ctx.textAlign = "center";
                ctx.fillText(ft.text, ft.x, ft.y);
                ctx.restore();
            }

            ctx.restore();
            updateUI();

            if (gameOver) {
                ctx.fillStyle = "rgba(7, 7, 16, 0.88)";
                ctx.fillRect(0, 0, canvas.width, canvas.height);
                ctx.fillStyle = "#fff";
                ctx.font = "bold 26px Inter, sans-serif";
                ctx.textAlign = "center";
                const winner = player1.hp > 0 ? player1.name : player2.name;
                ctx.fillText(`🏆 決鬥結束! ${winner} 獲得最終勝利!`, canvas.width / 2, canvas.height / 2 - 25);
                
                restartTimer--;
                const sec = Math.ceil(restartTimer / 60);
                ctx.font = "15px Inter, sans-serif";
                ctx.fillStyle = "#94a3b8";
                ctx.fillText(`將在 ${Math.max(1, sec)} 秒後自動重新開始決鬥...`, canvas.width / 2, canvas.height / 2 + 20);
                ctx.textAlign = "left";

                if (restartTimer <= 0) {
                    manualReset();
                }
            }

            requestAnimationFrame(gameLoop);
        }

        gameLoop();
    </script>
</body>
</html>

我要留言

立即登入留言